에이전트 워크플로우를 상태 머신으로 표현하기
에이전트 워크플로우를 상태 머신으로 표현하기
에이전트의 대화 기록에는 계획, 실행 결과, 오류와 사용자의 답변이 섞여 있다. 이를 그대로 작업 상태로 사용하면 재시작 뒤 어디서 이어야 하는지 판단하기 어렵다. 상태 머신은 현재 상태와 허용된 이벤트, 다음 상태, 실행해야 할 side effect를 명시한다. 대화는 근거와 표시 정보로 남기되 실제 실행 권한과 진행 여부는 영속화된 상태와 version으로 결정해야 한다.
목차
- #대화 기록은 작업 상태가 아니다
- #상태와 이벤트와 전이를 구분한다
- #업무 상태와 실행 상태를 분리한다
- #종단 상태를 먼저 정의한다
- #전이 표로 허용되는 행동을 고정한다
- #상태 전이와 Side Effect 사이의 간격
- #Outbox와 Inbox로 메시지 경계를 연결한다
- #재시도와 멱등성을 상태에 포함한다
- #사람의 입력을 기다리는 상태
- #취소와 보상은 별도 전이다
- #병렬 작업과 Join을 표현한다
- #Artifact가 상태의 근거가 된다
- #낙관적 잠금으로 동시 전이를 막는다
- #Event Sourcing과 Snapshot 선택
- #Workflow Version을 고정한다
- #재구성한 TypeScript 상태 머신
- #복구와 Reconciliation
- #테스트해야 할 불변 조건
- #관찰 지표와 운영 화면
- #마무리
- #참고 자료
- #관련 노트
대화 기록은 작업 상태가 아니다
에이전트가 저장소를 분석하고 수정한 뒤 사람의 승인을 받아 배포하는 흐름을 생각해 보자. 대화에는 다음과 같은 메시지가 쌓인다.
User: 오류 원인을 찾아 수정해 줘.
Agent: 로그와 코드를 확인하겠습니다.
Tool: command timed out
Agent: 다른 방식으로 다시 확인하겠습니다.
Tool: test passed
Agent: 수정이 끝났습니다. 배포할까요?
User: 응
마지막 응이 무엇을 승인했는지는 앞의 문맥을 재구성해야 알 수 있다. process가 재시작되거나 대화 일부가 압축되면 더 어려워진다.
- 수정 patch는 실제로 저장되었는가?
- timeout 난 명령은 실패했는가, 아직 실행 중인가?
- 어떤 test가 어느 revision에서 통과했는가?
- 사용자는 정확히 어느 patch를 승인했는가?
- 배포 message가 이미 queue에 들어갔는가?
- 같은 작업을 다시 시작하면 중복 배포되는가?
대화를 다시 model에 넣고 “현재 상태를 판단하라”고 할 수는 있다. 그러나 같은 기록도 model이나 prompt version에 따라 다르게 해석할 수 있고, 공격자가 작성한 텍스트가 상태 판단에 영향을 줄 수도 있다.
flowchart LR
C[대화와 Tool 출력] --> M[모델의 상태 추론]
M -->|매번 달라질 수 있음| A[다음 Action]더 안정적인 구조에서는 대화와 실행 상태를 분리한다.
flowchart LR
C[대화와 Tool 출력] --> E[근거 Artifact]
E --> T[검증된 Event]
T --> S[(영속 Workflow State)]
S --> P[허용된 다음 Action]
모델은 이벤트를 제안할 수 있지만 현재 상태와 허용된 전이를 결정하는 것은 workflow engine이다. Agent가 완료라고 말했다와 시스템이 COMPLETED로 전이했다는 서로 다른 사건이다.
상태와 이벤트와 전이를 구분한다
세 용어를 섞으면 구현이 모호해진다.
| 개념 | 의미 | 예시 |
|---|---|---|
| State | 특정 시점의 영속된 현재 상태 | AWAITING_APPROVAL |
| Event | 이미 발생한 사실 또는 전이 요청 | ApprovalGranted |
| Transition | 현재 상태와 이벤트로 다음 상태를 결정하는 규칙 | 승인 대기 + 승인 → 실행 준비 |
| Command | 외부에 수행하도록 요청하는 의도 | DeployArtifact |
| Activity | 실제 I/O와 side effect를 수행하는 실행 단위 | 배포 API 호출 |
nextState = transition(currentState, event)
Transition 함수는 가능하면 결정적으로 만든다. 같은 상태와 이벤트에는 같은 다음 상태와 command가 나와야 replay와 test가 쉽다.
type TransitionResult<S, C> = {
nextState: S;
commands: C[];
};
function transition<S, E, C>(state: S, event: E): TransitionResult<S, C> {
// 네트워크 호출이나 현재 시각 조회 없이 결정한다.
throw new Error("implemented by workflow definition");
}
현재 시각, random ID와 외부 조회가 필요하면 engine이 값이 포함된 event를 먼저 생성한다. 예를 들어 Date.now()로 만료를 판단하는 대신 TimerFired { firedAt } 이벤트를 기록한다.
업무 상태와 실행 상태를 분리한다
RUNNING, DONE, FAILED 세 상태만으로는 무엇이 실행 중인지 알 수 없다. 업무의 단계와 worker의 물리적 상태를 구분한다.
type BusinessStage =
| "ANALYZING"
| "IMPLEMENTING"
| "VERIFYING"
| "AWAITING_APPROVAL"
| "APPLYING";
type ExecutionStatus =
| "READY"
| "CLAIMED"
| "RUNNING"
| "RETRY_WAIT"
| "SUCCEEDED"
| "FAILED"
| "UNKNOWN";
예를 들어 workflow stage는 VERIFYING이지만 test activity는 RETRY_WAIT일 수 있다. 둘을 하나의 enum에 넣으면 가능한 조합마다 상태가 늘어난다.
VERIFYING_RUNNING
VERIFYING_RETRY_WAIT
VERIFYING_FAILED
APPLYING_RUNNING
APPLYING_UNKNOWN
...
업무 상태는 사용자와 orchestrator가 보는 진행 단계이고, execution 상태는 activity runner가 보는 전달·재시도 상태다. 별도 모델로 두고 관계를 명시한다.
외부 배포 API가 성공했지만 응답이 유실되면 FAILED가 아니라 결과를 모르는 상태다. 이를 실패로 축약하면 재시도가 중복 side effect를 만들 수 있다.
종단 상태를 먼저 정의한다
Happy path부터 그리면 실패와 취소가 임시 예외 처리로 남는다. 먼저 작업이 끝나는 모든 방식을 정한다.
type TerminalState =
| "COMPLETED"
| "REJECTED"
| "CANCELLED"
| "FAILED_FINAL"
| "EXPIRED";
각 종단 상태는 다른 의미를 가진다.
| 종단 상태 | 의미 | 재개 방식 |
|---|---|---|
| COMPLETED | 완료 조건과 결과가 검증됨 | 새 작업 생성 |
| REJECTED | 사람이 제안을 거부함 | 수정된 새 proposal 필요 |
| CANCELLED | 요청자가 중단함 | 정책에 따라 새 작업 생성 |
| FAILED_FINAL | 자동 복구 budget 소진 | 운영자 개입 또는 새 실행 |
| EXPIRED | 승인·입력 대기 시간 만료 | 최신 상태로 재검토 |
FAILED에서 무조건 재시작 버튼을 제공하면 오래된 승인과 artifact를 재사용할 수 있다. 새 실행이 같은 workflow instance를 이어 가는 것인지, 이전 결과를 참고한 새 instance인지 구분한다.
Cleanup도 종단 상태의 부수적인 관례가 아니라 확인할 event로 만든다.
WorkflowTerminated
-> CredentialsRevoked
-> SandboxDestroyed
-> RetentionScheduled
작업 결과는 완료됐지만 sandbox cleanup이 실패할 수 있다. 사용자에게는 완료로 보여도 보안 운영에서는 잔여 자원을 추적해야 한다.
전이 표로 허용되는 행동을 고정한다
다이어그램은 전체 구조를 보기 좋지만 세부 guard와 side effect를 놓치기 쉽다. 전이 표를 함께 둔다.
| 현재 상태 | 이벤트 | Guard | 다음 상태 | 생성 Command |
|---|---|---|---|---|
| QUEUED | WorkerClaimed | lease 유효 | ANALYZING | AnalyzeRepository |
| ANALYZING | AnalysisProduced | artifact 검증 | IMPLEMENTING | CreatePatch |
| IMPLEMENTING | PatchProduced | base hash 일치 | VERIFYING | RunChecks |
| VERIFYING | ChecksPassed | 필수 check 전부 성공 | AWAITING_APPROVAL | RequestApproval |
| VERIFYING | ChecksFailed | retry 가능 | VERIFYING | RunChecks |
| AWAITING_APPROVAL | ApprovalGranted | proposal hash 일치 | APPLYING | ApplyPatch |
| AWAITING_APPROVAL | ApprovalExpired | 현재 시각 이후 | EXPIRED | RevokeCapability |
| APPLYING | ApplyConfirmed | target revision 확인 | COMPLETED | Cleanup |
| APPLYING | OutcomeUnknown | 조회 가능 | RECONCILING | QueryTarget |
| 모든 비종단 상태 | CancellationRequested | 취소 가능 | CANCELLING | CancelActivities |
이 표에서 guard를 server-side code로 강제한다. UI가 승인 버튼을 숨겼다는 사실은 전이 규칙이 아니다.
function assertAllowed(state: WorkflowState, event: WorkflowEvent) {
const rule = transitionTable.find(
(candidate) =>
candidate.from === state.name && candidate.event === event.type,
);
if (!rule) {
throw new InvalidTransitionError(state.name, event.type);
}
rule.guard(state, event);
}
상태 전이와 Side Effect 사이의 간격
DB state를 바꾸고 외부 API를 호출하는 두 작업을 하나의 transaction으로 묶을 수 없는 경우가 많다.
await database.workflow.update(id, { state: "APPLYING" });
await deploymentApi.deploy(payload);
첫 줄 뒤 process가 죽으면 state는 APPLYING인데 API는 호출되지 않았다. 순서를 바꾸면 API는 성공했지만 state가 이전 상태로 남을 수 있다.
sequenceDiagram
participant DB as Workflow DB
participant W as Worker
participant X as External System
W->>DB: APPLYING으로 전이
W->>X: deploy(operationId)
X-->>W: 성공
W--xDB: 결과 저장 전 crash
Note over DB,X: DB만으로 실제 결과를 알 수 없음해결 방향은 state transition에서 직접 side effect를 수행하지 않고 command를 durable하게 생성하는 것이다.
transaction:
event 저장
state 갱신
command/outbox 저장
별도 worker:
command 전달
activity 결과 event 저장
외부 호출에는 workflow instance와 step에서 만든 안정적인 operation ID를 사용한다. 자세한 retry 계약은 에이전트 재시도와 멱등성과 연결된다.
Outbox와 Inbox로 메시지 경계를 연결한다
전이와 command 저장을 같은 DB transaction에 넣는 Outbox pattern은 메시지 유실을 줄인다.
BEGIN;
UPDATE workflow_instances
SET state = 'VERIFYING', version = version + 1, updated_at = now()
WHERE id = $1 AND version = $2;
INSERT INTO workflow_events (
id, workflow_id, event_type, payload, recorded_at
) VALUES ($3, $1, 'PatchProduced', $4, now());
INSERT INTO outbox_messages (
id, workflow_id, command_type, payload, published_at
) VALUES ($5, $1, 'RunChecks', $6, NULL);
COMMIT;
Publisher가 outbox를 queue에 전달한 뒤 published_at을 표시한다. 전송과 표시 사이에 crash하면 중복 발행될 수 있으므로 consumer의 Inbox가 command ID를 deduplicate한다.
INSERT INTO inbox_messages (consumer, message_id, received_at)
VALUES ('test-worker', $1, now())
ON CONFLICT (consumer, message_id) DO NOTHING
RETURNING message_id;
반환 행이 없으면 이미 처리했거나 처리 중인 message다. 다만 Inbox insert와 activity의 외부 side effect가 원자적이지 않을 수 있으므로 operation-level idempotency가 여전히 필요하다.
저장소와 queue, 외부 API 경계를 가로지르는 순간 중복이나 결과 불명확 상태가 생길 수 있다. 각 경계에서 식별자, 중복 제거와 reconciliation 수단을 설계한다.
재시도와 멱등성을 상태에 포함한다
재시도 횟수를 worker memory에만 두면 재시작 뒤 0으로 돌아간다. Activity instance에 시도 상태를 저장한다.
type ActivityExecution = {
activityId: string;
workflowId: string;
operationId: string;
status: "READY" | "RUNNING" | "RETRY_WAIT" | "SUCCEEDED" | "UNKNOWN";
attemptCount: number;
nextAttemptAt?: string;
deadlineAt: string;
lastErrorClass?: string;
};
stateDiagram-v2
[*] --> Ready
Ready --> Running: worker claim
Running --> Succeeded: result recorded
Running --> RetryWait: transient failure
Running --> Unknown: response lost after write
RetryWait --> Ready: durable timer
Unknown --> Reconciling: status query
Reconciling --> Succeeded: side effect found
Reconciling --> Ready: not executed and retry safe
Reconciling --> NeedsAttention: cannot determineRetry timer도 process의 setTimeout에만 맡기지 않는다. next_attempt_at을 저장하고 scheduler가 만료된 activity를 다시 준비 상태로 옮긴다. 서비스가 재시작되어도 기다림이 보존된다.
사람의 입력을 기다리는 상태
Agent가 질문하거나 승인을 기다리는 동안 process와 model connection을 유지할 필요가 없다. 필요한 prompt와 binding을 저장하고 workflow를 잠든 상태로 만든다.
type HumanWait = {
waitId: string;
workflowId: string;
kind: "CLARIFICATION" | "APPROVAL";
promptArtifactId: string;
expectedSchema: string;
boundProposalHash?: string;
requestedAt: string;
expiresAt: string;
status: "OPEN" | "ANSWERED" | "EXPIRED" | "CANCELLED";
};
사용자의 새 메시지가 도착하면 단순히 마지막 대화 뒤에 붙이지 않고 현재 open wait와 연결되는지 확인한다.
새 메시지 도착
-> 이 사용자가 답할 권한이 있는가
-> open wait가 아직 유효한가
-> 요구한 schema에 맞는가
-> proposal hash가 그대로인가
-> HumanInputReceived event 기록
질문을 기다리는 동안 workflow definition이 배포되어 바뀔 수도 있다. Instance가 어느 version으로 재개되는지도 고정해야 한다.
승인 상태는 사람의 승인을 영속 상태로 저장해야 하는 이유처럼 승인자, payload hash, 만료와 소비 상태를 별도 record로 보존한다.
취소와 보상은 별도 전이다
취소 flag를 하나 세우는 것으로는 실행 중인 activity와 이미 발생한 side effect를 처리할 수 없다.
stateDiagram-v2
Running --> Cancelling: CancellationRequested
Cancelling --> Cancelled: 모든 activity 중단 확인
Cancelling --> Compensating: 되돌릴 side effect 존재
Compensating --> Cancelled: 보상 완료
Compensating --> NeedsAttention: 보상 실패취소 요청이 오면 다음을 구분한다.
- 아직 예약만 된 command는 발행하지 않는다.
- 실행 중인 process에는 cancellation signal을 보낸다.
- 취소를 지원하지 않는 외부 operation은 완료 여부를 추적한다.
- 이미 반영된 변경은 업무상 compensation이 가능한지 판단한다.
- 되돌릴 수 없는 작업은 사용자에게 명확히 표시한다.
type CompensationPlan = {
originalOperationId: string;
compensationOperationId: string;
action: string;
inputArtifactHash: string;
approvalRequired: boolean;
};
보상 작업도 side effect이므로 독립적인 상태와 idempotency key가 필요하다. “실패하면 이전 상태로 되돌린다”는 한 줄은 실제 분산 시스템의 rollback을 보장하지 않는다.
병렬 작업과 Join을 표현한다
멀티 에이전트가 여러 subtask를 병렬로 수행할 때 parent state만 RUNNING으로 두면 어느 child가 끝났는지 알 수 없다.
type ChildRun = {
childId: string;
role: "API_ANALYSIS" | "DB_ANALYSIS" | "SECURITY_ANALYSIS";
inputHash: string;
status: "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED";
outputArtifactId?: string;
};
Join 정책을 명시한다.
| 정책 | 완료 조건 | 사용 예시 |
|---|---|---|
| ALL | 모든 child 성공 | 필수 검증 묶음 |
| ANY | 하나 이상 성공 | 대체 가능한 자료원 |
| QUORUM | N개 중 K개 성공 | 여러 독립 판정 |
| THRESHOLD | 실패율이 한도 이하 | 대량 item 처리 |
| FIRST_VALID | 검증을 통과한 첫 결과 | 여러 전략 경주 |
function evaluateJoin(children: ChildRun[]): "WAIT" | "PASS" | "FAIL" {
if (children.some((child) => child.status === "FAILED")) return "FAIL";
if (children.every((child) => child.status === "SUCCEEDED")) return "PASS";
return "WAIT";
}
위 함수는 ALL 정책의 단순 예다. 실패한 child를 재시도할지, 나머지를 취소할지, 부분 결과를 보존할지는 workflow policy에 포함한다. 역할 분할 기준은 멀티 에이전트 역할을 나누는 기준과 이어진다.
Artifact가 상태의 근거가 된다
PATCH_READY 상태라면 어떤 patch가 준비되었는지 반드시 가리켜야 한다. 상태 이름만 있고 근거 산출물이 없으면 재개할 수 없다.
type ArtifactRef = {
artifactId: string;
kind: "ANALYSIS" | "PATCH" | "TEST_RESULT" | "APPROVAL";
contentHash: string;
schemaVersion: string;
producedByRunId: string;
inputHashes: string[];
createdAt: string;
};
다음 불변 조건을 만들 수 있다.
state == VERIFYING
-> current patch artifact가 존재한다
state == AWAITING_APPROVAL
-> 필수 test artifact가 모두 성공이다
-> proposal hash가 current patch hash에 묶여 있다
state == COMPLETED
-> apply result가 target revision과 일치한다
Artifact를 immutable하게 저장하고 content hash로 참조하면 입력이 바뀐 뒤 오래된 결과를 사용하는 것을 탐지할 수 있다. 단, hash만 같다고 내용이 안전한 것은 아니므로 schema와 생산 주체, policy version도 검사한다.
낙관적 잠금으로 동시 전이를 막는다
두 worker가 같은 workflow를 동시에 진행하면 lost update가 생길 수 있다.
Worker A reads version 7, state VERIFYING
Worker B reads version 7, state VERIFYING
Worker A writes AWAITING_APPROVAL, version 8
Worker B writes FAILED, version 8
version을 조건에 넣어 한 전이만 성공하게 한다.
UPDATE workflow_instances
SET state = $1,
data = $2,
version = version + 1,
updated_at = now()
WHERE id = $3
AND version = $4
RETURNING id, state, version;
반환 행이 없으면 최신 state를 다시 읽고 이벤트가 여전히 적용 가능한지 판단한다. 같은 event ID를 중복 적용하지 않도록 unique constraint도 둔다.
CREATE UNIQUE INDEX workflow_event_dedup
ON workflow_events (workflow_id, event_id);
DB row lock을 오래 잡은 채 model이나 외부 API를 기다리지 않는다. Claim에는 짧은 lease를 사용하고, 실제 결과는 operation ID에 대해 멱등하게 기록한다.
Event Sourcing과 Snapshot 선택
현재 row만 저장하는 방식과 모든 event를 저장해 상태를 재구성하는 방식 사이에는 trade-off가 있다.
| 방식 | 장점 | 단점 |
|---|---|---|
| 현재 상태 row | 조회와 구현이 단순 | 전이 이유 복원이 어려움 |
| 상태 row + audit event | 실용적 균형 | 두 표현의 일관성 관리 |
| Event sourcing | 전체 이력과 replay | schema·version·성능 복잡도 |
대부분의 agent workflow는 상태 row, append-only event와 artifact store를 같은 transaction에서 갱신하는 방식으로 시작할 수 있다.
CREATE TABLE workflow_instances (
id UUID PRIMARY KEY,
definition_name TEXT NOT NULL,
definition_version INTEGER NOT NULL,
state TEXT NOT NULL,
data JSONB NOT NULL,
version BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE workflow_events (
sequence_id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL,
workflow_id UUID NOT NULL REFERENCES workflow_instances(id),
event_type TEXT NOT NULL,
event_version INTEGER NOT NULL,
payload JSONB NOT NULL,
actor_type TEXT NOT NULL,
actor_id TEXT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL,
UNIQUE (workflow_id, event_id)
);
Event sourcing을 선택하면 orchestrator replay는 deterministic해야 한다. Microsoft Durable Functions 문서도 event history로 orchestrator 상태를 재구성하므로 현재 시각이나 random처럼 재생마다 달라지는 API를 직접 사용하지 말아야 한다고 설명한다.
Workflow Version을 고정한다
수일 동안 대기 중인 workflow가 있는데 code를 배포해 전이 순서를 바꾸면 기존 instance가 새 코드에서 해석되지 않을 수 있다.
version 1: ANALYZE -> IMPLEMENT -> APPROVE -> APPLY
version 2: ANALYZE -> PLAN -> APPROVE -> IMPLEMENT -> APPLY
Version 1의 IMPLEMENT 이후 멈춘 instance가 version 2 code로 재개되면 이미 지나간 approval 순서가 맞지 않는다. Instance 생성 시 definition version을 고정한다.
type WorkflowIdentity = {
instanceId: string;
definitionName: "repository-change";
definitionVersion: 3;
};
배포 전략은 다음 중 하나다.
- 이전 version worker를 실행 중 instance가 끝날 때까지 유지한다.
- 호환 가능한 전이만 조건부 version branch로 처리한다.
- 명시적 migration event로 state와 artifact를 변환한다.
- 오래된 instance를 종료하고 사람의 확인을 받아 새 instance를 만든다.
Migration은 단순 DB update가 아니다. 이전 승인이 새 workflow 의미에도 유효한지, 진행 중 side effect가 있는지 검토해야 한다.
재구성한 TypeScript 상태 머신
다음 코드는 개념을 보여 주기 위한 가상의 예시다. 실제 workflow engine 대신 transition을 순수 함수에 가깝게 두었다.
type State =
| { name: "QUEUED" }
| { name: "ANALYZING"; analysisRunId: string }
| { name: "IMPLEMENTING"; analysisArtifactId: string }
| { name: "VERIFYING"; patchArtifactId: string; attempt: number }
| { name: "AWAITING_APPROVAL"; proposalHash: string; expiresAt: string }
| { name: "APPLYING"; operationId: string }
| { name: "RECONCILING"; operationId: string }
| { name: "COMPLETED"; resultArtifactId: string }
| { name: "FAILED_FINAL"; reason: string };
type Event =
| { type: "AnalysisStarted"; runId: string }
| { type: "AnalysisProduced"; artifactId: string }
| { type: "PatchProduced"; artifactId: string }
| { type: "ChecksPassed"; proposalHash: string; expiresAt: string }
| { type: "ChecksFailed"; retryable: boolean; reason: string }
| { type: "ApprovalGranted"; proposalHash: string; operationId: string }
| { type: "ApplySucceeded"; operationId: string; resultArtifactId: string }
| { type: "ApplyOutcomeUnknown"; operationId: string };
type Command =
| { type: "RunAnalysis"; runId: string }
| { type: "CreatePatch"; analysisArtifactId: string }
| { type: "RunChecks"; patchArtifactId: string; attempt: number }
| { type: "RequestApproval"; proposalHash: string; expiresAt: string }
| { type: "ApplyPatch"; operationId: string; proposalHash: string }
| { type: "ReconcileApply"; operationId: string };
function evolve(state: State, event: Event): TransitionResult<State, Command> {
if (state.name === "QUEUED" && event.type === "AnalysisStarted") {
return {
nextState: { name: "ANALYZING", analysisRunId: event.runId },
commands: [{ type: "RunAnalysis", runId: event.runId }],
};
}
if (state.name === "ANALYZING" && event.type === "AnalysisProduced") {
return {
nextState: { name: "IMPLEMENTING", analysisArtifactId: event.artifactId },
commands: [{ type: "CreatePatch", analysisArtifactId: event.artifactId }],
};
}
if (state.name === "IMPLEMENTING" && event.type === "PatchProduced") {
return {
nextState: { name: "VERIFYING", patchArtifactId: event.artifactId, attempt: 1 },
commands: [{ type: "RunChecks", patchArtifactId: event.artifactId, attempt: 1 }],
};
}
if (state.name === "VERIFYING" && event.type === "ChecksPassed") {
return {
nextState: {
name: "AWAITING_APPROVAL",
proposalHash: event.proposalHash,
expiresAt: event.expiresAt,
},
commands: [{
type: "RequestApproval",
proposalHash: event.proposalHash,
expiresAt: event.expiresAt,
}],
};
}
if (state.name === "VERIFYING" && event.type === "ChecksFailed") {
if (!event.retryable || state.attempt >= 3) {
return {
nextState: { name: "FAILED_FINAL", reason: event.reason },
commands: [],
};
}
return {
nextState: { ...state, attempt: state.attempt + 1 },
commands: [{
type: "RunChecks",
patchArtifactId: state.patchArtifactId,
attempt: state.attempt + 1,
}],
};
}
if (state.name === "AWAITING_APPROVAL" && event.type === "ApprovalGranted") {
if (state.proposalHash !== event.proposalHash) {
throw new Error("approval is bound to another proposal");
}
return {
nextState: { name: "APPLYING", operationId: event.operationId },
commands: [{
type: "ApplyPatch",
operationId: event.operationId,
proposalHash: event.proposalHash,
}],
};
}
if (state.name === "APPLYING" && event.type === "ApplyOutcomeUnknown") {
if (state.operationId !== event.operationId) {
throw new Error("operation mismatch");
}
return {
nextState: { name: "RECONCILING", operationId: state.operationId },
commands: [{ type: "ReconcileApply", operationId: state.operationId }],
};
}
if (state.name === "APPLYING" && event.type === "ApplySucceeded") {
return {
nextState: { name: "COMPLETED", resultArtifactId: event.resultArtifactId },
commands: [],
};
}
throw new InvalidTransitionError(state.name, event.type);
}
예시는 모든 상태를 다 구현하지 않았지만 핵심 구조를 보여 준다. 전이 함수는 외부 I/O를 하지 않고 command를 반환한다. Engine은 event, next state와 command를 transaction에 저장한 뒤 worker가 command를 실행하게 한다.
복구와 Reconciliation
서비스 재시작 뒤 복구는 “마지막 메시지를 다시 model에 보낸다”가 아니다.
1. workflow instance와 version 조회
2. 마지막 committed event와 current state 대조
3. 미발행 outbox message 재전송
4. lease가 만료된 activity 탐지
5. RUNNING 또는 UNKNOWN activity의 외부 상태 확인
6. open timer와 human wait 재등록
7. 허용된 전이로만 workflow 재개
Reconciler는 주기적으로 기대 상태와 실제 외부 상태를 비교한다.
async function reconcileApply(activity: ActivityExecution) {
const target = await deploymentApi.findByOperationId(activity.operationId);
if (target?.status === "SUCCEEDED") {
return events.applySucceeded(target.resultArtifactId);
}
if (target?.status === "RUNNING") {
return commands.scheduleReconciliation(activity.operationId);
}
if (!target && activityCanSafelyRetry(activity)) {
return commands.retryApply(activity.operationId);
}
return events.manualAttentionRequired("external outcome cannot be determined");
}
Watchdog가 RUNNING 시간이 길다는 이유만으로 즉시 FAILED로 덮어쓰면 안 된다. lease, heartbeat, 외부 operation과 idempotency 계약을 확인한다.
테스트해야 할 불변 조건
상태 머신은 예시 경로보다 불가능해야 할 상태를 test할 때 가치가 드러난다.
- 검증 artifact 없이 AWAITING_APPROVAL에 들어갈 수 없다.
- 승인된 proposal hash와 APPLYING command의 hash는 같다.
- 하나의 event ID는 한 번만 상태를 바꾼다.
- 종단 상태에서는 업무 side effect command를 새로 만들지 않는다.
- 취소 뒤 새 activity가 시작되지 않는다.
- COMPLETED에는 검증 가능한 result artifact가 존재한다.
- UNKNOWN 외부 작업은 확인 없이 새 operation ID로 재실행되지 않는다.
- workflow definition version은 instance 수명 동안 임의로 바뀌지 않는다.
it("rejects approval for a stale proposal", () => {
const state: State = {
name: "AWAITING_APPROVAL",
proposalHash: "sha256:new",
expiresAt: "2026-05-15T01:00:00Z",
};
expect(() => evolve(state, {
type: "ApprovalGranted",
proposalHash: "sha256:old",
operationId: "op-1",
})).toThrow("approval is bound to another proposal");
});
Property-based test로 임의 event sequence를 생성해 불변 조건을 확인할 수도 있다. DB integration test에서는 같은 version에 대한 동시 update, outbox 중복 발행과 worker crash를 실제 transaction으로 검증한다.
AWS Step Functions처럼 workflow 서비스가 제공하는 Retry와 Catch를 사용하더라도 어떤 오류가 retry 가능한지, 재시도되는 activity가 멱등한지, fallback 이후 업무 상태가 무엇인지 애플리케이션이 정의해야 한다.
관찰 지표와 운영 화면
대화 UI만으로 운영하면 멈춘 workflow와 기다리는 workflow를 구분하기 어렵다. 상태별 수와 체류 시간을 본다.
| 지표 | 확인할 문제 |
|---|---|
| instances by state | 특정 단계 backlog |
| state dwell time | 비정상적으로 오래 머무는 상태 |
| invalid transition count | bug, stale client 또는 공격 |
| activity attempts | 재시도 증폭 |
| unknown outcome age | 결과 미확정 위험 |
| human wait age | 응답·승인 지연 |
| outbox unpublished age | message publisher 장애 |
| lease expiration count | worker crash 또는 timeout |
| reconciliation success rate | 자동 복구 가능성 |
| version distribution | 오래된 workflow 정리 필요 |
| orphan artifact count | 상태와 산출물 lifecycle 불일치 |
운영 화면에는 최소한 다음을 표시한다.
Workflow ID / Definition version
Current state / Entered at / Version
Current artifact hashes
Open activity와 attempt
Open human wait와 expiry
Last event / Last error class
Pending outbox command
External operation ID
Available operator transitions
운영자도 DB state를 직접 수정하지 않고 RetryRequested, CancellationRequested, ManualResultConfirmed 같은 감사 가능한 event를 제출하게 한다. 수동 전이는 현재 상태와 권한을 검사하고 이유를 필수로 받는다.
마무리
에이전트 workflow를 대화 기록만으로 운영하면 model이 현재 상태를 매번 추론해야 한다. 짧은 demo에서는 자연스럽지만 승인 대기, service restart, queue redelivery와 외부 side effect가 들어오는 순간 복구 기준이 흔들린다.
대화는 작업을 설명하지만, 영속 상태와 검증된 이벤트가 작업을 진행시킨다.
실무 적용 기준은 다음과 같다.
- 상태, 이벤트, command와 activity를 서로 다른 개념으로 둔다.
- Model은 전이를 제안하고 workflow engine이 허용 여부를 검사한다.
- 업무 단계와 worker 실행 상태를 분리한다.
- 완료·거부·취소·최종 실패·만료를 종단 상태로 정의한다.
- 전이 표에 guard, 다음 상태와 생성 command를 명시한다.
- Event, state와 outbox command를 같은 transaction에 저장한다.
- Consumer inbox와 operation ID로 중복 전달을 흡수한다.
- Retry count, deadline과 결과 불명 상태를 영속화한다.
- Human wait를 schema, proposal hash와 expiry에 묶는다.
- 병렬 child의 Join 정책과 부분 실패 처리를 명시한다.
- State가 참조하는 artifact를 immutable hash로 보존한다.
- Version 조건부 update로 동시 전이를 하나만 허용한다.
- 실행 중 instance의 workflow definition version을 고정한다.
- Reconciler가 DB 기대 상태와 외부 실제 상태를 비교하게 한다.
- 불가능한 전이와 crash 지점 중심으로 test한다.
상태 머신의 목적은 agent의 유연성을 없애는 것이 아니다. 계획과 탐색은 model에게 맡기되, 실행과 복구에 필요한 사실을 명확한 상태로 남겨 장시간 작업도 예측 가능하게 이어 가는 것이다.
참고 자료
- AWS Step Functions - Handling errors in workflows
- AWS Step Functions - Task workflow state
- Microsoft Learn - Durable orchestrator code constraints
- Microsoft Learn - Durable Task programming model
- Microsoft Learn - Orchestration versioning